Skip to content

feat(map-analysis): 3D terrain view + DEM tile proxy (#3826 Phase 2)#4239

Merged
Yeraze merged 8 commits into
mainfrom
feature/3826-3d-map-foundation
Jul 21, 2026
Merged

feat(map-analysis): 3D terrain view + DEM tile proxy (#3826 Phase 2)#4239
Yeraze merged 8 commits into
mainfrom
feature/3826-3d-map-foundation

Conversation

@Yeraze

@Yeraze Yeraze commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Summary

Phase 2 of the 3D Map & Elevation epic (#3826): MeshMonitor gets its first real 3D map. The Map Analysis page gains a 2D/3D toggle — 3D renders a standalone MapLibre GL view with DEM terrain, hillshade, pitch/bearing navigation, a terrain-exaggeration slider, and node markers. The browser gets DEM tiles through a new server proxy that honors the admin-configured elevation source and never exposes its URL or API key. Leaflet remains untouched for 2D; the two stacks coexist behind the toggle.

Changes

Backend

  • GET /api/elevation/tiles/:z/:x/:y — terrarium raster-dem PNG proxy (optionalAuth, new elevationTileLimiter 600/min prod, Cache-Control: public, max-age=604800, immutable; errors are enveloped fail() + no-store: 403 ELEVATION_DISABLED, 409 TERRAIN_TILES_UNAVAILABLE, 400 INVALID_TILE_COORDS, 502 TILE_FETCH_FAILED). Raw-PNG LRU (256 entries, ~30 MB ceiling) separate from the existing decoded-Float32 profile cache; reuses safeFetch/SSRF guard.
  • GET /api/elevation/capabilities — derived {enabled, terrainTiles, provider} (server-side because elevationSourceUrl is secret). A configured JSON point source reports terrainTiles:false — deliberately no silent fallback to public AWS tiles.

Frontend

  • src/components/map/Base3DMap.tsx — standalone MapLibre GL component (not the Leaflet adapter): terrarium raster-dem terrain + hillshade, initial pitch 60°, NavigationControl with pitch, exaggeration slider (0–2×, default 1.3×), GeoJSON node circle+label layers, click→select, map.remove() teardown (StrictMode-safe). Graceful WebGL-unavailable fallback: probe + try/catch → message + onUnsupported → Map Analysis auto-reverts to 2D (found via browser validation — previously a no-WebGL browser with persisted 3D crashed to a blank page).
  • src/config/basemap3d.ts — Leaflet {s} subdomain expansion for MapLibre tiles[], vector-tileset → OSM raster fallback (with on-map note), base-path-aware proxy URL.
  • useTerrainCapabilities hook; viewMode: '2d'|'3d' persisted in the existing mapAnalysis.config.v1; toolbar toggle disabled with specific tooltips per unavailability reason; force-2D guard so a stale persisted '3d' never strands the user.
  • 2D path untouched — the existing Leaflet tree renders verbatim when viewMode==='2d'.

Docs: Map Analysis 3D section, settings elevation note, REST API entries for both endpoints, README feature bullet, epic plan + implementation spec in dev-notes.

Issues Resolved

Relates to #3826 (Phase 2 of 3). Phase 1 was #4235.

Documentation Updates

  • docs/features/map-analysis.md (3D terrain view section + toolbar table row)
  • docs/features/settings.md (elevation source ↔ 3D availability)
  • docs/api/REST_API.md (capabilities + tiles endpoints)
  • README.md (Key Features bullet)
  • Dev-notes: 3D_MAP_FOUNDATION_SPEC.md, MAP_3D_ELEVATION_EPIC.md status

Testing

  • Full suite green: 10,511 tests / 0 failures, success: true via JSON reporter (route tests use createRouteTestApp harness; maplibre-gl fully mocked in jsdom)
  • TypeScript + npm run lint:ci clean (no baseline growth)
  • Live-validated on dev container: tile proxy serves real PNGs with immutable caching; 403/400 machine codes verified; capabilities envelope verified
  • 3D rendering validated in Chrome+SwiftShader (pitched terrain, markers, exaggeration slider, user's tileset as basemap, 18 DEM tiles via proxy — screenshot in epic notes)
  • Gating validated live: elevation disabled → toggle disabled w/ tooltip; persisted-3D + no WebGL → graceful auto-revert to 2D (no crash); viewMode persistence round-trips reload
  • Reviewer: on Map Analysis, click the cube (Box) toolbar icon → pitched 3D terrain with node markers; toggle back → 2D unchanged

🤖 Generated with Claude Code

https://claude.ai/code/session_018e4dtLyWeYJYJ7SbgFvGG1

Yeraze and others added 7 commits July 20, 2026 16:29
… viewMode config (#3826 Phase 2 WP-B)

Adds the frontend pure/config building blocks for the MapLibre 3D map:
basemap3d.ts (expandSubdomains for Leaflet {s} URLs, resolve3DBasemap with
vector-tileset osm fallback, buildTerrainTileUrl honoring the deployment
base path), useTerrainCapabilities (reads the enveloped
/api/elevation/capabilities response with safe loading defaults), and a
persisted viewMode ('2d'|'3d', default '2d') on the existing Map Analysis
config, exposed through MapAnalysisContext via the existing config spread.
…ase 2 WP-A)

Backend-only half of the 3D Map Foundation epic: a server-side terrarium
DEM tile proxy and a derived, non-secret capabilities endpoint so the
frontend can gate the upcoming 3D toggle without ever seeing the secret
elevationSourceUrl setting.

- elevationProvider.ts: isValidTileCoord + fetchTerrariumTilePng (raw-PNG
  fetch via safeFetch, separate 256-entry LRU from the decoded sample
  cache), MAX_TERRARIUM_TILE_ZOOM=15.
- elevationService.ts: getElevationCapabilities (enabled/terrainTiles/
  provider) and fetchTerrainTile, which returns a discriminated result
  (ELEVATION_DISABLED 403, TERRAIN_TILES_UNAVAILABLE 409 for configured
  JSON sources — no silent terrarium fallback, INVALID_TILE_COORDS 400,
  TILE_FETCH_FAILED 502, or { png }).
- rateLimiters.ts: elevationTileLimiter (600/min prod, 3000/min dev),
  far more generous than the existing 20/min elevationLimiter since a
  single 3D view fetches dozens of tiles.
- elevationRoutes.ts: GET /capabilities (optionalAuth, ok() envelope) and
  GET /tiles/:z/:x/:y (optionalAuth + tile limiter; raw image/png bytes +
  immutable Cache-Control on success, fail() + no-store on error; never
  echoes the source URL/key).
- Extends the three backend test files (harness-based route tests mocking
  only safeFetch; provider/service unit tests) per the WP-A test plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e4dtLyWeYJYJ7SbgFvGG1
Generic, Map-Analysis-agnostic MapLibre GL surface: raster basemap, raster-dem
terrain + hillshade from the DEM proxy, node circle/label layers, exaggeration
slider, NavigationControl + compact attribution, and symmetric mount/unmount
lifecycle (map.remove() on unmount, StrictMode double-mount safe).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e4dtLyWeYJYJ7SbgFvGG1
- MapAnalysisToolbar: 3D View toggle button gated by useTerrainCapabilities
  (disabled + "Elevation is disabled" / "3D terrain is unavailable with the
  configured elevation source" tooltips; neutral-disabled while loading;
  active class reflects viewMode)
- MapAnalysisCanvas: viewMode==='3d' branch renders Base3DMap fed from the
  same useAnalysisNodes() data (mapped to Node3DFeature), basemap via
  resolve3DBasemap, terrain tiles via buildTerrainTileUrl(appBasename),
  node click -> setSelected({type:'node',...}); 2D tree untouched
- Force-2D guard: persisted '3d' with unavailable capabilities renders 2D
  and corrects the stored config (no flash-to-2D while caps are loading)
- Vector-only tileset: non-blocking fallback note over the 3D map
- Tests: toolbar gating/toggle/active-state matrix; canvas 2d-vs-3d branch,
  mapped node data, terrain URL, vector-fallback note, force-2D guard
  (Base3DMap + useTerrainCapabilities mocked at module level — no WebGL
  in jsdom)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e4dtLyWeYJYJ7SbgFvGG1
…3826 Phase 2)

Real-machine case: WebGL context creation can fail, making
`new maplibregl.Map(...)` throw uncaught and leaving the 3D pane blank with
the user stranded in 3D mode.

- Base3DMap: cheap webgl2/webgl canvas probe before construction PLUS
  try/catch around the Map constructor (probe can pass while the real
  context still fails). On failure: no crash, centered "3D view requires
  WebGL" fallback message, and a new optional onUnsupported prop invoked
  exactly once per instance (ref-guarded, StrictMode double-mount safe).
- MapAnalysisCanvas: onUnsupported -> setViewMode('2d') so the user lands
  back on the working 2D map (and the persisted viewMode is corrected).
- Tests: probe-failure + constructor-throw + StrictMode exactly-once +
  no-slider-in-fallback for Base3DMap; unsupported-signal-flips-to-2D for
  the canvas. jsdom returns null for WebGL contexts, so the suite stubs
  HTMLCanvasElement#getContext truthy by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e4dtLyWeYJYJ7SbgFvGG1
Adds user-facing docs for the new pitched-terrain MapLibre GL view: a
"3D terrain view" section in map-analysis.md (toggle, exaggeration
slider, requirements, basemap fallback, what's deferred to a later
phase), a settings.md note that the elevation source also powers 3D
and that JSON point sources don't support it, the two new
GET /api/elevation/capabilities and GET /api/elevation/tiles/{z}/{x}/{y}
endpoints in REST_API.md, and a README.md feature-list entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e4dtLyWeYJYJ7SbgFvGG1
@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(map-analysis): 3D terrain view + DEM tile proxy (#3826 Phase 2)

This is a well-structured, thoughtfully-engineered PR. The code is clean, the security model is sound, and the test coverage is thorough. Below are my findings, organized by severity.


Security

No issues found. The sensitive areas were handled correctly:

  • The elevation source URL and API key are never echoed in responses, logs, or the capabilities envelope (tested at elevationRoutes.test.ts:226,276).
  • All outbound HTTP goes through safeFetch/SSRF guard — no raw fetch in the proxy path.
  • Tile coordinate validation (parseTileParam + isValidTileCoord) rejects extensions like 123.png, non-integers, negative coords, and zoom beyond the terrarium max (15) before any URL substitution occurs.
  • The tile proxy uses optionalAuth (not public-anonymous), consistent with the /profile endpoint.

Potential Bugs / Issues

1. getAllSettings() called once per tile request — no settings cache
src/server/routes/elevationRoutes.ts:161

const settings = await databaseService.settings.getAllSettings();

The tile proxy fetches all settings on every request. A single 3D map view fires dozens of tile requests concurrently; at 600 req/min that's 600 settings DB reads/min. The existing /profile route does the same, but it's rate-limited at 20/min. Since settings rarely change, caching them for a few seconds (or using a dedicated getSettingAsync('elevationEnabled') + getSettingAsync('elevationSourceUrl') pair) would substantially reduce DB pressure. This isn't a correctness bug, but worth noting for high-traffic deployments.

2. rawTileCache is module-scoped and shared across tests — can cause order-dependent test failures
src/server/services/elevationProvider.ts:157

const rawTileCache = new LruCache<string, Buffer>(RAW_TILE_CACHE_MAX);

The SSRF-blocked test in elevationRoutes.test.ts:297 already works around this by using a distinct tile coordinate (8/41/97 instead of 8/40/96). This is a valid mitigation, but the pattern is fragile — a future test that inadvertently reuses a coordinate warmed by a previous test will get a cache hit and bypass mockSafeFetch. A clearForTesting() export or resetting the module between tests would make this more robust. The same concern applies to tileCache and jsonCache, though those existed before this PR.

3. Basemap change removes the layer before the source — order matters if MapLibre defers
src/components/map/Base3DMap.tsx:275-276

if (map.getLayer(BASEMAP_LAYER_ID)) map.removeLayer(BASEMAP_LAYER_ID);
if (map.getSource(BASEMAP_SOURCE_ID)) map.removeSource(BASEMAP_SOURCE_ID);

This is the correct order (remove layer, then source). Just confirming it's right — MapLibre will throw if you try to remove a source that still has layers referencing it. ✓

4. basemap3d.ts:99 — unknown tileset falls back to OSM without setting usedFallback: true
src/config/basemap3d.ts:98-102

it('falls back to the default tileset (osm) for an unknown tileset id', () => {
    const result = resolve3DBasemap('does-not-exist', []);
    expect(result.usedFallback).toBe(false);  // ← test asserts usedFallback:false

If a user has a tileset ID that's no longer valid (e.g., removed from TILESETS), getTilesetById silently returns the OSM fallback but usedFallback is set to false. The on-map note ("Showing default basemap in 3D — the selected map style is vector-only") won't appear, even though the user's tileset isn't being used. Depending on getTilesetById's contract, it may be intentional, but it's worth noting.


Performance Considerations

5. DEM tile proxy performs a settings read on every tile fetch
(See item 1 above — repeating here under performance since it's the bigger concern.)

6. toNodesFeatureCollection is called on every nodes or loaded change
src/components/map/Base3DMap.tsx:293-297
This is called both in the nodes sync effect and at map construction time (via the map.on('load', ...) closure). For large node sets this is a minor concern, but the function isn't memoized. A useMemo wrapping toNodesFeatureCollection(nodes) at the component level would avoid the double computation when loaded flips true while nodes is unchanged (effect runs twice on that render cycle — once from the initial run since loaded just changed, once since nodes didn't). Low priority.


Code Quality / Best Practices

7. console.warn in Base3DMap.tsx
src/components/map/Base3DMap.tsx:181

console.warn('Base3DMap: WebGL map construction failed', err);

The rest of the codebase uses the project logger (src/utils/logger.js) for structured logging. console.warn is fine here since this is a frontend component (logger is backend), and this matches what MapLibre itself does — no change needed. Just noting it's intentional.

8. The useMemo(() => buildTerrainTileUrl(appBasename), []) empty-deps array
src/components/MapAnalysis/MapAnalysisCanvas.tsx:135

const terrainTileUrl = useMemo(() => buildTerrainTileUrl(appBasename), []);

appBasename is a module-scope constant, so this is technically correct and the eslint react-hooks/exhaustive-deps exception in the mount effect (// eslint-disable-next-line) handles the more complex case. However, useMemo with [] is equivalent to a useRef initialized once — consider making it a module constant instead since appBasename never changes:

const TERRAIN_TILE_URL = buildTerrainTileUrl(appBasename);

This is stylistic — the current code is functionally correct.

9. MapLibre maxzoom: 15 hardcoded in Base3DMap.tsx
src/components/map/Base3DMap.tsx:200

maxzoom: 15,

MAX_TERRARIUM_TILE_ZOOM is already exported from elevationProvider.ts (= 15). Importing and using it here would keep the two in sync if the constant ever changes, though 15 is the AWS terrarium dataset's fixed native zoom, so in practice this won't drift. Minor.


Test Coverage

Coverage is excellent:

  • Route-level integration tests via createRouteTestApp harness ✓ (correct per CLAUDE.md)
  • Unit tests for all service/provider functions ✓
  • Component tests with a full FakeMap that tracks all MapLibre API calls ✓
  • WebGL-unavailable paths tested (probe fail + constructor throw + StrictMode double-mount) ✓
  • Secret/key leak assertions in both capabilities and tile error responses ✓
  • Cache ordering issue documented and worked around with distinct tile coords ✓

One gap: There's no test for the basemap-change effect (removing and re-adding the raster source when basemap prop changes). The node-update path is tested in Base3DMap.test.tsx:248, but the basemap swap isn't. Low priority since the MapLibre API calls (removeLayer/removeSource/addSource/addLayer) are already on the FakeMap, making such a test easy to add.


Summary

This is a high-quality implementation. The security-sensitive paths (URL/key isolation, SSRF guard, coordinate validation) are all handled correctly and tested. The two items most worth addressing before merge are:

  1. Settings read per tile — consider caching or using targeted getSetting calls to reduce DB pressure under the 600/min tile rate limiter.
  2. Module-scoped cache isolation in tests — the coordinate-dodge workaround works but is fragile; a clearForTesting() export would be safer.

Everything else is minor or stylistic. The 2D/Leaflet path is provably untouched by the feature flag logic, the force-2D guard prevents stale-persisted-3D from stranding users, and the WebGL fallback is solid.

…lation (PR #4239 review)

- elevationRoutes.ts: replace getAllSettings() (full settings-map load) with
  a shared readElevationSettings() helper that reads only elevationEnabled +
  elevationSourceUrl via settings.getSetting() — the tile route can be hit
  up to 600/min, so per-request full-map loads were needless DB pressure.
  Behavior unchanged.
- elevationProvider.ts: export clearRawTileCacheForTesting() so tests can
  empty the module-scope raw-PNG LRU.
- elevationProvider.test.ts / elevationRoutes.test.ts: clear the raw-tile
  cache in beforeEach so tile-path test isolation no longer depends on each
  test using distinct z/x/y coordinates; drop the coordinate-dodge comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e4dtLyWeYJYJ7SbgFvGG1
@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(map-analysis): 3D terrain view + DEM tile proxy (#3826 Phase 2)

Tasks

  • Gather context and understand the PR
  • Review backend changes (elevationRoutes, elevationProvider, elevationService)
  • Review frontend changes (Base3DMap, MapAnalysisCanvas, basemap3d)
  • Review hooks and test coverage
  • Post final review

This is a well-engineered PR. The security model is sound, the architecture is clean, and the test coverage is thorough. Below are my findings organized by severity.


Security — No Issues

The sensitive areas are all handled correctly:

  • elevationSourceUrl and API keys are never echoed in responses, logs, or the capabilities envelope — verified by secret-leak assertions in both the capabilities and tile-error test cases.
  • All outbound HTTP goes through safeFetch/SSRF guard. No raw fetch in the proxy path.
  • parseTileParam + isValidTileCoord reject extensions like 123.png, non-integers, negatives, and zoom beyond the terrarium max (15) before any URL substitution.
  • The tile proxy is optionalAuth (not fully public), consistent with the /profile endpoint.

Bugs / Issues

1. The profile route still uses getAllSettings() while the tile route now uses readElevationSettings() — inconsistency
src/server/routes/elevationRoutes.ts:62

The PR correctly introduced readElevationSettings() (two targeted getSetting calls) to reduce DB pressure on the tile proxy, and the review in the previous comment identified this as a concern for the tile route. But the fix was applied only to the tile route — POST /profile (line 62) still calls getAllSettings(). The two routes should use the same helper for consistency:

// POST /profile (line 62) — still uses getAllSettings()
const settings = await databaseService.settings.getAllSettings();

While /profile is rate-limited at 20/min vs. 600/min for tiles, the inconsistency is worth noting and the fix is trivial.

2. Unknown tileset ID falls back to OSM but usedFallback is false — silent substitution
src/config/basemap3d.ts:45-63 and src/config/basemap3d.test.ts:98-102

When getTilesetById receives an unknown ID (not a predefined ID, not in customTilesets), it silently returns TILESETS[DEFAULT_TILESET_ID] (OSM). In resolve3DBasemap, the tileset.isVector check fails to trigger for OSM (since OSM is a raster tileset), so usedFallback is set to false. Result: a user with a stale/deleted tileset ID silently gets OSM in 3D with no on-map note.

The test at line 98-102 actually documents this as expected behavior (usedFallback: false), but it may not be the intended UX. Worth a deliberate decision: should getTilesetById returning the default fallback trigger usedFallback: true? The contract would need to be documented if the current behavior is intentional.

3. INVALID_TILE_COORDS validation occurs after TERRAIN_TILES_UNAVAILABLE — means a JSON source with invalid coords returns 409, not 400
src/server/services/elevationService.ts:261-284

The validation order in fetchTerrainTile checks caps.provider !== 'terrarium' (→ 409) before isValidTileCoord (→ 400). A request to /tiles/999/0/0 with a JSON source URL returns 409 rather than 400. This is debatable — the spec says "validation order: first failure wins" — but it means coordinate validation can be bypassed by observing the 409 vs. 400 response code, revealing information about the server's elevation source type even when the request has obviously invalid coords. Very minor, but worth noting for defense-in-depth.


Performance

4. rawTileCache is module-scoped and cleared in beforeEach by the new clearRawTileCacheForTesting() export

The prior review noted the cache isolation concern; the export clearRawTileCacheForTesting (src/server/services/elevationProvider.ts:165-167) now exists and is used in beforeEach (elevationRoutes.test.ts:80). This fully resolves the earlier fragility concern. ✓

5. toNodesFeatureCollection is called twice per render when loaded flips to true
src/components/map/Base3DMap.tsx:292-297

The nodes effect deps include [nodes, loaded]. When loaded becomes true, this effect fires and calls setData with the same nodes that were already passed to addSource (line 212) in the map.on('load', ...) handler that just set loaded = true. So the GeoJSON source gets its data set twice on initial load. This is harmless (a redundant setData) but worth noting. A simple guard:

// Inside the nodes effect:
if (!map || !loaded) return;
const source = map.getSource(NODES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
// Only need to call setData for *changes* after initial load —
// the load handler already supplied the initial data.
source?.setData(toNodesFeatureCollection(nodes));

The current code is functionally correct; this is a minor optimization.


Code Quality / Best Practices

6. maxzoom: 15 hardcoded in Base3DMap.tsx — use the exported constant
src/components/map/Base3DMap.tsx:200

MAX_TERRARIUM_TILE_ZOOM is already exported from elevationProvider.ts (line 61). Using it here would keep the two values in sync:

// src/components/map/Base3DMap.tsx line 200
maxzoom: MAX_TERRARIUM_TILE_ZOOM,

The literal 15 is the AWS terrarium dataset's fixed max zoom so it won't drift in practice, but the constant is already there.

7. useMemo(() => buildTerrainTileUrl(appBasename), []) — equivalent to a module constant
src/components/MapAnalysis/MapAnalysisCanvas.tsx:135

appBasename is a module-scope constant, so useMemo with [] is functionally equivalent to:

const TERRAIN_TILE_URL = buildTerrainTileUrl(appBasename); // module-level

Either way is correct. The useMemo is slightly more idiomatic for values that depend on module-scope imports in React components, but if appBasename were somehow unstable, the [] dep array would be a bug. Since it's a constant, making it a module-level constant is more explicit.

8. AttributionControl double-lists basemap attribution
src/components/map/Base3DMap.tsx:188-193

map.addControl(
  new maplibregl.AttributionControl({
    compact: true,
    customAttribution: [basemap.attribution, ELEVATION_ATTRIBUTION],
  }),

The basemap source already has an attribution field in the style definition (line 162: attribution: basemap.attribution). MapLibre automatically reads source.attribution and shows it in the attribution control. Passing it again in customAttribution may cause it to appear twice in the attribution popup. Worth verifying in the live demo.


Test Coverage

Coverage is excellent overall:

  • Route-level integration tests via createRouteTestApp harness ✓ (correct per CLAUDE.md)
  • Unit tests for service/provider functions ✓
  • Base3DMap tests with a full FakeMap tracking all MapLibre API calls ✓
  • WebGL-unavailable paths (probe fail + constructor throw + StrictMode double-mount) ✓
  • Secret/key leak assertions on both capabilities and tile error responses ✓
  • clearRawTileCacheForTesting() now used in beforeEach — cache isolation is solid ✓

One gap: no test for the basemap-change effect
src/components/map/Base3DMap.tsx:271-289

The basemap-swap path (removeLayerremoveSourceaddSourceaddLayer) isn't exercised by any test in Base3DMap.test.tsx. The FakeMap already tracks all these calls (removeLayer, removeSource, addSource, addLayer), so adding a rerender test with a different basemap prop would be straightforward and would confirm the correct layer/source insertion order.


Summary

Must address before merge:

  • None — no blocking bugs found.

Worth addressing:

  1. Item 8: Double-attribution in AttributionControl — verify in the live demo that basemap attribution doesn't appear twice.
  2. Item 2: Clarify/document the intended behavior when an unknown tileset ID is passed to resolve3DBasemap — is usedFallback: false correct there?

Nice to have:

  • Item 1: Bring POST /profile in line with the tile route by using readElevationSettings() instead of getAllSettings().
  • Item 6: Use MAX_TERRARIUM_TILE_ZOOM constant instead of hardcoded 15 in Base3DMap.tsx.
  • Item 9: Add a basemap-change effect test.

The 2D/Leaflet path is provably untouched, the force-2D guard prevents stale-persisted-3D from stranding users, the WebGL fallback is solid, and the security-sensitive paths (URL/key isolation, SSRF guard, coordinate validation) are all correctly handled and tested.

@Yeraze
Yeraze merged commit ad9992e into main Jul 21, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant